iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Software Development

Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app系列 第 13

Kotlin 物件導向進階——抽象類別、介面、Inner Class 與 Object

  • 分享至 

  • xImage
  •  

前幾天已經學過 Kotlin 類別、物件、建構子與繼承。今天繼續進入 Kotlin 物件導向的進階內容,包含抽象類別、介面、多介面實作、巢狀類別、內部類別、單例物件、伴生物件、匿名物件與函式式介面。參考資料:Kotlin-Classes

今天會學到:

  1. 使用 abstract 建立抽象類別。
  2. 使用 interface 定義類別必須具備的能力。
  3. 在類別中實作一個或多個介面。
  4. 解決多個介面具有相同函式時的衝突。
  5. 比較巢狀類別與內部類別。
  6. 使用 object 建立單例物件。
  7. 使用 companion object 建立類別層級的成員。
  8. 使用匿名物件臨時覆寫類別或實作介面。
  9. 使用 fun interface 與 Lambda 簡化程式。
  10. 將介面應用於付款策略設計。

參考資料:Kotlin-InterfacesKotlin-Object declarations


一、抽象類別是什麼?

抽象類別是一種不能直接建立物件的類別,主要用途是定義一組共同的屬性與函式,再交由子類別完成實際內容。宣告抽象類別時,需要在 class 前面加上 abstract。參考資料:Kotlin-Abstract classes

abstract class Base

抽象類別中可以同時包含:

  • 抽象屬性。
  • 一般屬性。
  • 抽象函式。
  • 已經完成的一般函式。

抽象屬性和抽象函式只規定「必須有什麼」,不提供完整實作;繼承它的子類別必須使用 override 完成這些成員。參考資料:Kotlin-Inheritance


二、抽象類別完整範例

以下是上傳資料 P11-28_As(1).kt 的抽象類別範例,經過命名與輸出方式整理後如下。參考資料:Kotlin-Abstract classes

/**
 * 抽象父類別 Base。
 *
 * 抽象類別不能直接建立物件,
 * 主要用來規範子類別必須實作的成員。
 */
abstract class Base {

    // 抽象屬性沒有初始值,必須由子類別實作。
    abstract var a: Int

    // 一般屬性已經提供初始值,子類別可以直接使用。
    var b: Int = 0

    // 抽象函式沒有函式內容,子類別必須覆寫。
    abstract fun fun1()

    // 一般函式已經完成實作,子類別可以直接呼叫。
    fun fun2() {
        println("From Base class")
    }
}

/**
 * Derived 繼承抽象類別 Base。
 *
 * 因為 Base 包含抽象屬性 a 與抽象函式 fun1(),
 * 所以 Derived 必須使用 override 完成實作。
 */
class Derived : Base() {

    // 完成父類別規定的抽象屬性。
    override var a: Int = 0

    // 完成父類別規定的抽象函式。
    override fun fun1() {
        println("From Derived class")
    }
}

fun main() {

    // Base 是抽象類別,不能寫成 Base()。
    // 必須建立具體子類別 Derived 的物件。
    val myClass = Derived()

    println("a = ${myClass.a}")
    println("b = ${myClass.b}")

    // fun1() 與 fun2() 的回傳型別都是 Unit,
    // 因此直接呼叫即可,不需要放進 println()。
    myClass.fun1()
    myClass.fun2()
}

執行結果:

a = 0
b = 0
From Derived class
From Base class

原始參考程式使用以下寫法:

println(myClass.fun1())
println(myClass.fun2())

因為 fun1()fun2() 沒有宣告其他回傳型別,所以回傳型別為 Unit。這樣寫除了函式原本的輸出之外,還會再印出 kotlin.Unit。如果只是想執行函式,直接呼叫即可。參考資料:Kotlin-Functions


三、抽象類別實作:形狀計算器

第一份練習要求建立 Shape 抽象類別,再建立 CircleRectangle 子類別,分別覆寫 area() 計算圓形與矩形面積。參考資料:Kotlin-Inheritance

import kotlin.math.PI

/**
 * 所有形狀共同的抽象父類別。
 *
 * 每一種具體形狀都必須提供自己的面積計算公式。
 */
abstract class Shape {

    /**
     * 計算形狀面積。
     *
     * @return 形狀面積
     */
    abstract fun area(): Double
}

/**
 * 圓形。
 *
 * @property radius 圓形半徑
 */
class Circle(
    private val radius: Double
) : Shape() {

    /**
     * 圓面積公式:π × 半徑平方。
     */
    override fun area(): Double {
        return PI * radius * radius
    }
}

/**
 * 矩形。
 *
 * @property width 矩形寬度
 * @property height 矩形高度
 */
class Rectangle(
    private val width: Double,
    private val height: Double
) : Shape() {

    /**
     * 矩形面積公式:寬 × 高。
     */
    override fun area(): Double {
        return width * height
    }
}

fun main() {

    // 建立半徑為 6 的圓形。
    val circle = Circle(radius = 6.0)

    // 建立寬 12、高 16 的矩形。
    val rectangle = Rectangle(
        width = 12.0,
        height = 16.0
    )

    println("圓形面積 = %.2f".format(circle.area()))
    println("矩形面積 = %.2f".format(rectangle.area()))
}

執行結果:

圓形面積 = 113.10
矩形面積 = 192.00

Shape 不知道每種圖形的面積公式,因此只定義 area()。真正的公式分別放在 CircleRectangle,這就是抽象類別的核心精神:父類別規範共同結構,子類別完成不同實作。參考資料:Kotlin-Abstract classes


四、抽象類別實作:員工薪資計算

第二個抽象類別練習是建立員工薪資系統。正職員工與兼職員工都是員工,因此可以共同繼承 Employee;但兩種員工的薪資算法不同,所以交由各個子類別覆寫 calculateSalary()。參考資料:Kotlin-Inheritance

/**
 * 員工抽象父類別。
 *
 * @property name 員工姓名
 */
abstract class Employee(
    val name: String
) {

    /**
     * 計算員工薪資。
     *
     * 不同員工類型具有不同計算方式,
     * 因此交由子類別完成。
     */
    abstract fun calculateSalary(): Double
}

/**
 * 正職員工。
 *
 * @property name 員工姓名
 * @property monthlySalary 每月薪資
 */
class FullTimeEmployee(
    name: String,
    private val monthlySalary: Double
) : Employee(name) {

    /**
     * 計算正職員工年薪。
     *
     * 年薪=月薪 × 12。
     */
    override fun calculateSalary(): Double {
        return monthlySalary * 12
    }
}

/**
 * 兼職員工。
 *
 * @property name 員工姓名
 * @property hourlyRate 每小時薪資
 * @property workingHours 當月工作時數
 */
class PartTimeEmployee(
    name: String,
    private val hourlyRate: Double,
    private val workingHours: Int
) : Employee(name) {

    /**
     * 計算兼職員工月薪。
     *
     * 月薪=時薪 × 工作時數。
     */
    override fun calculateSalary(): Double {
        return hourlyRate * workingHours
    }
}

fun main() {

    // Alice 是正職員工,月薪為 50,000 元。
    val fullTimeEmployee = FullTimeEmployee(
        name = "Alice",
        monthlySalary = 50_000.0
    )

    // Bob 是兼職員工,時薪 300 元、工作 60 小時。
    val partTimeEmployee = PartTimeEmployee(
        name = "Bob",
        hourlyRate = 300.0,
        workingHours = 60
    )

    println(
        "${fullTimeEmployee.name} 的年薪為:${
            "%.2f".format(fullTimeEmployee.calculateSalary())
        } 元"
    )

    println(
        "${partTimeEmployee.name} 的月薪為:${
            "%.2f".format(partTimeEmployee.calculateSalary())
        } 元"
    )
}

執行結果:

Alice 的年薪為:600000.00 元
Bob 的月薪為:18000.00 元

因為兩個物件都繼承 Employee,所以還能放進同一個 List<Employee>,再透過相同的 calculateSalary() 呼叫不同實作,這就是多型。參考資料:Kotlin-Inheritance

val employees: List<Employee> = listOf(
    FullTimeEmployee("Alice", 50_000.0),
    PartTimeEmployee("Bob", 300.0, 60)
)

employees.forEach { employee ->
    println(
        "${employee.name}:${
            "%.2f".format(employee.calculateSalary())
        } 元"
    )
}

五、介面是什麼?

介面使用 interface 宣告,用來定義類別應具備的能力或行為。例如「可以繪圖」、「可以點擊」、「可以計算面積」或「可以付款」。參考資料:Kotlin-Interfaces

interface Drawable {
    fun draw()
}

實作介面的類別需要在類別名稱後面加入冒號及介面名稱:

class SimpleCircle : Drawable

如果介面的函式沒有提供預設內容,實作類別就必須使用 override 完成它:

class SimpleCircle : Drawable {

    override fun draw() {
        println("Drawing a circle")
    }
}

介面與一般父類別不同,一個類別只能直接繼承一個父類別,卻可以同時實作多個介面。參考資料:Kotlin-Interfaces


六、介面屬性與預設函式

上傳的 interface(1).kt 定義了 IntB 介面。它要求實作類別提供數學和英文成績,並提供完成的 show() 預設函式。參考資料:Kotlin-Properties in interfaces

/**
 * 成績顯示介面。
 *
 * 介面可以宣告抽象屬性,
 * 但不能像一般類別一樣直接保存實例狀態。
 */
interface IntB {

    // 實作類別必須提供數學成績。
    var math: Int

    // 實作類別必須提供英文成績。
    var eng: Int

    /**
     * 介面也可以提供具有函式內容的預設實作。
     */
    fun show() {
        println("math = $math, eng = $eng")
    }
}

/**
 * Report 透過主建構子直接實作介面的兩個屬性。
 */
class Report(
    override var math: Int = 0,
    override var eng: Int = 0
) : IntB

fun main() {

    val report = Report(
        math = 95,
        eng = 88
    )

    // Report 沒有重新覆寫 show(),
    // 因此會直接使用 IntB 提供的預設實作。
    report.show()
}

執行結果:

math = 95, eng = 88

Report 沒有函式主體也沒有覆寫 show(),仍然可以呼叫 show(),因為 IntB 已經提供預設實作。參考資料:Kotlin-Implementing interfaces


七、一個類別實作多個介面

上傳的 interface2(1).kt 使用 DrawableClick 介面,讓 Button 同時具備繪圖和點擊能力。原始資料中的 Darwable 應是 Drawable 的拼字錯誤,以下程式已經修正。參考資料:Kotlin-Interfaces

/**
 * 定義可以繪製的能力。
 */
interface Drawable {
    fun draw()
}

/**
 * 定義可以點擊的能力。
 */
interface Click {
    fun click()
}

/**
 * SimpleCircle 只具備繪圖能力。
 */
class SimpleCircle : Drawable {

    override fun draw() {
        println("Drawing a circle")
    }
}

/**
 * Button 同時實作 Drawable 與 Click。
 *
 * Kotlin 使用逗號分隔多個介面。
 */
open class Button : Drawable, Click {

    override fun draw() {
        println("Drawing a button")
    }

    override fun click() {
        println("Button clicked")
    }
}

fun main() {

    val circle = SimpleCircle()
    circle.draw()

    val button = Button()
    button.draw()
    button.click()
}

執行結果:

Drawing a circle
Drawing a button
Button clicked

這個範例可以把介面理解成不同能力的組合:

類別 Drawable Click
SimpleCircle
Button

Button 不需要為了取得兩種能力而同時繼承兩個類別,只要實作兩個介面即可。參考資料:Kotlin-Interfaces


八、使用 Shape 介面計算圓形資料

抽象類別能定義共同狀態和行為;如果只希望規定類別必須提供哪些能力,也可以使用介面。上傳資料中的 Shape 介面要求所有形狀都提供 area()perimeter()。參考資料:Kotlin-Interfaces

import kotlin.math.PI

/**
 * 所有形狀必須具備面積與周長計算能力。
 */
interface Shape {

    fun area(): Double

    fun perimeter(): Double
}

/**
 * 圓形實作 Shape 介面。
 *
 * @property radius 圓形半徑
 */
class Circle(
    private val radius: Double
) : Shape {

    /**
     * 圓周長=2 × π × 半徑。
     */
    override fun perimeter(): Double {
        return 2 * PI * radius
    }

    /**
     * 圓面積=π × 半徑平方。
     */
    override fun area(): Double {
        return PI * radius * radius
    }

    /**
     * 顯示完整的圓形資料。
     */
    fun info() {
        println("圓形半徑 = $radius")
        println("圓形面積 = %.2f".format(area()))
        println("圓形周長 = %.2f".format(perimeter()))
    }
}

fun main() {

    val circle = Circle(radius = 3.0)
    circle.info()
}

執行結果:

圓形半徑 = 3.0
圓形面積 = 28.27
圓形周長 = 18.85

九、多個介面出現相同函式怎麼辦?

參考圖片中的 Report 同時實作 IntAIntB。如果兩個介面都提供名稱與參數完全相同的 show() 預設函式,Kotlin 無法自行決定要採用哪一個版本,因此 Report 必須覆寫 show()。參考資料:Kotlin-Resolving overriding conflicts

Report 同時實作 IntA 與 IntB

完整範例如下:

/**
 * 學生基本資料介面。
 */
interface IntA {

    var name: String
    var id: Int

    /**
     * 顯示學生姓名與學號。
     */
    fun show() {
        println("name = $name, id = $id")
    }
}

/**
 * 學生成績介面。
 */
interface IntB {

    var math: Int
    var eng: Int

    /**
     * 顯示數學與英文成績。
     */
    fun show() {
        println("math = $math, eng = $eng")
    }
}

/**
 * Report 同時實作 IntA 與 IntB。
 *
 * 因為兩個介面都有 show(),
 * 所以 Report 必須自行覆寫並解決衝突。
 */
class Report(
    override var name: String,
    override var id: Int,
    override var math: Int,
    override var eng: Int
) : IntA, IntB {

    override fun show() {

        // 指定呼叫 IntA 版本的 show()。
        super<IntA>.show()

        println("score:")

        // 指定呼叫 IntB 版本的 show()。
        super<IntB>.show()
    }
}

fun main() {

    val report = Report(
        name = "Alex",
        id = 1001,
        math = 95,
        eng = 88
    )

    report.show()
}

執行結果:

name = Alex, id = 1001
score:
math = 95, eng = 88

以下語法代表明確指定要呼叫哪個介面的預設函式:

super<IntA>.show()
super<IntB>.show()

如果只寫成 super.show(),編譯器不知道要選擇 IntA.show() 還是 IntB.show(),因此會產生錯誤。參考資料:Kotlin-Resolving overriding conflicts


十、抽象類別與介面的差異

抽象類別和介面都能規定子類別必須實作哪些成員,但用途並不完全相同。參考資料:Kotlin-ClassesKotlin-Interfaces

比較項目 抽象類別 介面
宣告方式 abstract class interface
能否直接建立物件 不可以 不可以
建構子 可以有 不可以有一般建構子
保存物件狀態 可以 介面屬性不能直接保存 backing field
一般函式 可以 可以提供預設實作
抽象函式 可以 可以
類別可使用的數量 只能繼承一個父類別 可以實作多個介面
適合用途 建立具有共同狀態的類別家族 定義類別的能力或契約

簡單來說:

  • 「它是什麼」通常適合使用繼承或抽象類別。
  • 「它會做什麼」通常適合使用介面。

例如 Circle 是一種 Shape,可以設計成繼承關係;Button 可以繪圖也可以點擊,則可以實作 DrawableClick。實際設計仍需依資料狀態、擴充需求與類別關係判斷。參考資料:Kotlin-InheritanceKotlin-Interfaces


十一、巢狀類別 Nested Class

Kotlin 在一個類別中宣告另一個類別時,預設建立的是巢狀類別。巢狀類別不會持有外部類別物件的參考,因此不能直接存取外部類別的屬性或函式。參考資料:Kotlin-Nested classes

/**
 * 外部類別。
 */
class Outer {

    val message = "Hello from Outer"

    /**
     * 巢狀類別。
     *
     * 它不會持有 Outer 物件的參考,
     * 因此不能直接讀取 message。
     */
    class Nested {

        fun greet(): String {
            return "Hello from Nested"
        }
    }
}

fun main() {

    val outer = Outer()
    println(outer.message)

    // Nested 不需要先建立 Outer 物件,
    // 直接透過 Outer.Nested() 建立即可。
    val nested = Outer.Nested()
    println(nested.greet())
}

執行結果:

Hello from Outer
Hello from Nested

巢狀類別的建立方式是:

val nested = Outer.Nested()

不能透過 outer.Nested() 建立,因為 Nested 不屬於某個特定的 Outer 物件。參考資料:Kotlin-Nested classes


十二、內部類別 Inner Class

如果希望巢狀的類別能夠存取外部物件,就要在類別前加入 inner。內部類別會持有外部類別實例的參考,因此能直接使用外部類別的屬性和函式。參考資料:Kotlin-Inner classes

/**
 * 包含內部類別的外部類別。
 */
class OuterIn {

    val message = "Hello from OuterIn"

    fun outerFun() {
        println("This is OuterIn function")
    }

    /**
     * 使用 inner 宣告內部類別。
     *
     * Inner 能直接存取 OuterIn 的成員。
     */
    inner class Inner {

        /**
         * 直接讀取外部類別的 message。
         */
        fun greet(): String {
            return "Message is $message"
        }

        /**
         * 直接呼叫外部類別的 outerFun()。
         */
        fun innerFun() {
            outerFun()
        }
    }
}

fun main() {

    // 必須先建立外部類別物件。
    val outerIn = OuterIn()

    outerIn.outerFun()

    // 內部類別屬於特定的 outerIn 物件,
    // 因此透過該物件建立 Inner。
    val inner = outerIn.Inner()

    println(inner.greet())
    inner.innerFun()
}

執行結果:

This is OuterIn function
Message is Hello from OuterIn
This is OuterIn function

巢狀類別與內部類別的差異如下。參考資料:Kotlin-Nested and inner classes

比較項目 Nested Class Inner Class
宣告方式 class Nested inner class Inner
持有外部物件參考 不會
存取外部屬性 不可以直接存取 可以直接存取
建立方式 Outer.Nested() outer.Inner()
記憶體關係 與外部物件分開 依附於外部物件

Android 的共用點擊事件監聽器經常使用 inner class,因為監聽器需要讀取 Activity 的 binding 或其他畫面元件。不過,內部類別會持有 Activity 參考,所以不應讓監聽器的生命週期超過 Activity。參考資料:Android Developers-Memory overview


十三、object 單例物件

使用一般類別時,每次呼叫建構子都會建立一個新物件;使用 object 宣告時,整個程式只會共用同一個物件,因此很適合建立記錄器、設定管理器或共用工具。參考資料:Kotlin-Object declarations

/**
 * 全程共用的記錄器。
 *
 * Logger 是單例物件,不需要也不能使用 Logger() 建立。
 */
object Logger {

    // 記錄 log() 被呼叫的次數。
    private var count: Int = 0

    /**
     * object 第一次被存取時執行初始化區塊。
     */
    init {
        println("Logger initialized")
    }

    /**
     * 輸出記錄並增加次數。
     */
    fun log(message: String) {
        println("Log: $message")
        count++
    }

    /**
     * 取得目前記錄次數。
     */
    fun logCount(): Int {
        return count
    }
}

fun main() {

    println("count = ${Logger.logCount()}")

    Logger.log("Log test 1")
    println("count = ${Logger.logCount()}")

    Logger.log("Log test 2")
    println("count = ${Logger.logCount()}")
}

執行結果:

Logger initialized
count = 0
Log: Log test 1
count = 1
Log: Log test 2
count = 2

呼叫單例物件時,直接使用物件名稱:

Logger.log("開始執行")

不能寫成:

val logger = Logger()

因為 object 本身就是唯一的實例,不會提供一般建構子。參考資料:Kotlin-Object declarations


十四、object 產生亂數陣列

上傳資料也使用 object 建立亂數產生器。以下版本保留原本功能,並改善物件名稱與參數命名。參考資料:Kotlin-Object declarations

/**
 * 共用的亂數產生器。
 */
object RandomNumberGenerator {

    /**
     * 要產生的亂數數量。
     *
     * setter 會確保數量至少為 1。
     */
    var numberCount: Int = 1
        set(value) {
            field = if (value < 1) {
                1
            } else {
                value
            }
        }

    /**
     * 產生指定數量、範圍為 1 到 100 的亂數。
     */
    fun generateNumbers(): IntArray {

        return IntArray(numberCount).apply {

            for (index in indices) {
                this[index] = (1..100).random()
            }
        }
    }
}

fun main() {

    RandomNumberGenerator.numberCount = 5

    val numbers =
        RandomNumberGenerator.generateNumbers()

    numbers.forEachIndexed { index, number ->
        println("numbers[$index] = $number")
    }
}

如果把 numberCount 設為 0 或負數,自訂 setter 會自動將它改成 1。setter 中的 field 代表該屬性真正保存數值的 backing field;如果在 setter 裡再次寫 numberCount = value,就會不斷呼叫自己而造成無限遞迴。參考資料:Kotlin-Getters and setters


十五、companion object 伴生物件

companion object 宣告在類別內部,裡面的成員可以透過類別名稱直接存取,不必先建立類別物件。它常被用來放常數、工廠方法,以及 Android Fragment 的 newInstance()。參考資料:Kotlin-Companion objects

/**
 * 使用伴生物件建立 User。
 */
class User private constructor(
    val name: String
) {

    companion object {

        const val TAG = "User"

        /**
         * 工廠方法:統一負責建立 User。
         */
        fun create(name: String): User {
            return User(name)
        }
    }

    fun showInfo() {
        println("TAG = $TAG, name = $name")
    }
}

fun main() {

    // 不需要建立 companion object,
    // 直接使用類別名稱呼叫 create()。
    val user = User.create("Alice")

    user.showInfo()

    // 常數也能透過類別名稱存取。
    println(User.TAG)
}

執行結果:

TAG = User, name = Alice
User

伴生物件看起來很像 Java 的 static,但實際上它仍然是一個物件,也能實作介面。參考資料:Kotlin-Companion objects


十六、Android Fragment 的 newInstance 寫法

上傳的 Object_2(1).kt 示範使用伴生物件建立 Fragment。實際 Android 開發時,Fragment 的輸入參數應放入 argumentsBundle,這樣系統重新建立 Fragment 時才能還原資料。參考資料:Android Developers-Pass data between fragments

import android.os.Bundle
import androidx.fragment.app.Fragment

/**
 * 顯示詳細資料的 Fragment。
 */
class DetailFragment : Fragment() {

    companion object {

        private const val ARG_PARAM_1 = "param1"
        private const val ARG_PARAM_2 = "param2"

        /**
         * 建立 DetailFragment 並保存傳入參數。
         */
        fun newInstance(
            param1: String,
            param2: String
        ): DetailFragment {

            return DetailFragment().apply {

                arguments = Bundle().apply {
                    putString(ARG_PARAM_1, param1)
                    putString(ARG_PARAM_2, param2)
                }
            }
        }
    }

    /**
     * 從 arguments 讀取參數。
     */
    fun readParameters() {

        val param1 =
            arguments?.getString(ARG_PARAM_1)

        val param2 =
            arguments?.getString(ARG_PARAM_2)

        println("param1 = $param1")
        println("param2 = $param2")
    }
}

建立 Fragment 時可以直接呼叫:

val detailFragment = DetailFragment.newInstance(
    param1 = "Value 1",
    param2 = "Value 2"
)

不建議只將 Fragment 參數存成一般屬性,因為 Fragment 被系統重新建立後,一般屬性可能回到初始狀態;使用 arguments 才能配合 Fragment 的生命週期保存建立參數。參考資料:Android Developers-Fragment lifecycle


十七、匿名物件 Object Expression

如果只需要使用一次某個類別的特殊版本,就不一定要另外宣告新類別,可以使用 object : 類別或介面 建立匿名物件。參考資料:Kotlin-Object expressions

/**
 * 可被繼承的汽車類別。
 */
open class Car(
    val brand: String
) {

    open fun basicInfo(name: String): String {
        println("Car brand = $brand, name = $name")
        return "Salesperson name is $name"
    }
}

fun main() {

    // 建立一般 Car 物件。
    var myCar: Car = Car("BMW")
    println(myCar.basicInfo("John"))

    println()

    // 使用匿名物件建立一個特殊版本的 Car,
    // 不必再額外宣告 ToyotaCar 類別。
    myCar = object : Car("Toyota") {

        override fun basicInfo(name: String): String {
            println("This car brand is $brand")
            return "Salesperson name is $name"
        }
    }

    println(myCar.basicInfo("Mary"))
}

匿名物件也可以直接實作介面:

interface Click {
    fun click()
}

fun main() {

    // 建立只使用一次的 Click 實作。
    val button = object : Click {

        override fun click() {
            println("Button clicked")
        }
    }

    button.click()
}

匿名物件適合一次性、內容短小的實作。如果相同邏輯會重複使用,建立有名稱的類別通常更容易閱讀與測試。參考資料:Kotlin-Object expressions


十八、fun interface 函式式介面

如果介面只包含一個抽象函式,可以使用 fun interface 宣告函式式介面。Kotlin 能透過 SAM 轉換,直接使用 Lambda 建立該介面的實作。參考資料:Kotlin-Functional interfaces

/**
 * 只包含一個抽象函式的函式式介面。
 */
fun interface Action {
    fun execute()
}

/**
 * 接收任何 Action 並執行。
 */
fun runAction(action: Action) {

    println("Start runAction")

    action.execute()

    println("End runAction")
}

fun main() {

    // 因為 Action 是 fun interface,
    // 所以可以直接使用 Lambda 實作 execute()。
    runAction {
        println("Executing action 1")
    }

    println()

    runAction {
        println("Executing action 2")
    }
}

執行結果:

Start runAction
Executing action 1
End runAction

Start runAction
Executing action 2
End runAction

它也可以使用完整匿名物件寫法:

runAction(
    object : Action {

        override fun execute() {
            println("Executing anonymous implementation")
        }
    }
)

但在只有一個抽象函式而且邏輯很短的情況下,Lambda 通常更簡潔。參考資料:Kotlin-SAM conversions


十九、修正 Object_3 範例的兩個問題

上傳資料中有以下寫法:

runAction {
    object : Action {
        override fun execute() {
            println("Executing implementation!")
        }
    }
}

最外層 Lambda 並沒有執行匿名物件的 execute(),只是在 Lambda 中建立一個物件後就結束,因此看不到 Executing implementation!。正確方式可以直接使用 Lambda:

runAction {
    println("Executing implementation!")
}

或者建立匿名物件後直接傳入:

runAction(
    object : Action {

        override fun execute() {
            println("Executing implementation!")
        }
    }
)

參考資料:Kotlin-Functional interfaces

原始 DoAction 還有一個 TODO()

private fun action() {
    TODO("Not yet implemented")
}

TODO() 會直接丟出 NotImplementedError,所以程式執行到這裡就會中止。應該先完成函式內容:

class DoAction : Action {

    override fun execute() {
        println("Executing DoAction!")
        action()
    }

    /**
     * 已完成實作,不再使用 TODO()。
     */
    private fun action() {
        println("DoAction internal action")
    }
}

參考資料:Kotlin-TODO


二十、介面實戰:支付策略 PaymentStrategy

ex_9.txt 要求建立信用卡與現金付款。這個題目很適合使用策略模式:Checkout 不需要知道各種付款方式的內部細節,只要接收一個符合 PaymentMethod 介面的物件。參考資料:Kotlin-Interfaces

/**
 * 所有付款方式共同遵守的介面。
 */
interface PaymentMethod {

    /**
     * 執行付款。
     *
     * @param amount 付款金額
     * @return true 表示付款成功,false 表示付款失敗
     */
    fun pay(amount: Int): Boolean
}

/**
 * 信用卡付款策略。
 */
class CreditCard : PaymentMethod {

    override fun pay(amount: Int): Boolean {

        // 金額必須大於零。
        if (amount <= 0) {
            println("信用卡付款失敗:金額必須大於 0 元")
            return false
        }

        println("使用信用卡付款:$amount 元")
        return true
    }
}

/**
 * 現金付款策略。
 */
class Cash : PaymentMethod {

    override fun pay(amount: Int): Boolean {

        // 金額必須大於零。
        if (amount <= 0) {
            println("現金付款失敗:金額必須大於 0 元")
            return false
        }

        println("使用現金付款:$amount 元")
        return true
    }
}

/**
 * 結帳類別。
 *
 * Checkout 只依賴 PaymentMethod 介面,
 * 不依賴特定的信用卡或現金類別。
 */
class Checkout {

    /**
     * 執行完整付款流程。
     *
     * @param amount 付款金額
     * @param method 本次使用的付款策略
     */
    fun processPayment(
        amount: Int,
        method: PaymentMethod
    ) {

        val isSuccessful = method.pay(amount)

        if (isSuccessful) {
            println("付款成功")
        } else {
            println("付款失敗")
        }
    }
}

fun main() {

    // 建立結帳系統。
    val checkout = Checkout()

    // 建立兩種付款策略。
    val creditCard = CreditCard()
    val cash = Cash()

    // 使用信用卡支付 10,000 元。
    checkout.processPayment(
        amount = 10_000,
        method = creditCard
    )

    println("--------------------")

    // 使用現金支付 500 元。
    checkout.processPayment(
        amount = 500,
        method = cash
    )
}

執行結果:

使用信用卡付款:10000 元
付款成功
--------------------
使用現金付款:500 元
付款成功

Checkout 接收的型別是 PaymentMethod

fun processPayment(
    amount: Int,
    method: PaymentMethod
)

因此未來新增行動支付時,只要再建立一個實作 PaymentMethod 的類別,不需要修改 Checkout 原本的付款流程。參考資料:Kotlin-Interfaces

/**
 * 行動支付策略。
 */
class MobilePayment : PaymentMethod {

    override fun pay(amount: Int): Boolean {

        if (amount <= 0) {
            println("行動支付失敗:金額必須大於 0 元")
            return false
        }

        println("使用行動支付:$amount 元")
        return true
    }
}

這種設計降低了結帳流程與特定付款工具之間的耦合,也更容易加入新的付款方式。參考資料:Android Developers-Architecture recommendations


二十一、今天各種物件建立方式比較

今天接觸了多種看似相近、用途卻不同的語法。參考資料:Kotlin-Object declarations and expressions

寫法 是否有類別名稱 物件數量 主要用途
class User 可以建立多個 一般類別
abstract class Shape 本身不能建立 規範子類別
interface Click 本身不能建立 定義能力
class Nested 可以建立多個 不依附外部物件
inner class Inner 可以建立多個 需要存取外部物件
object Logger 全程一個 單例物件
companion object 隸屬於類別 每個類別一個 常數與工廠方法
object : Click 沒有 當次建立一個 匿名物件
fun interface Action 可用 Lambda 建立 單一抽象行為

今天學到的重點

今天從抽象類別開始,進一步學習了 Kotlin 組織物件與行為的不同方式。參考資料:Kotlin-Classes

  1. abstract class 不能直接建立物件。
  2. 抽象屬性和抽象函式必須由子類別覆寫。
  3. 抽象類別也可以包含一般屬性與已完成的函式。
  4. interface 適合定義類別應具備的能力。
  5. 介面可以包含抽象屬性與預設函式。
  6. 一個類別可以同時實作多個介面。
  7. 多個介面有相同預設函式時,必須解決覆寫衝突。
  8. super<IntA>.show() 可以指定呼叫特定介面的預設函式。
  9. 巢狀類別不會持有外部類別實例。
  10. inner class 可以直接存取外部類別成員。
  11. object 可建立全程共用的單例物件。
  12. companion object 適合放置常數及工廠方法。
  13. 匿名物件適合實作只使用一次的特殊行為。
  14. fun interface 可以透過 Lambda 簡化單一抽象函式。
  15. 付款策略示範了如何依賴介面,而不是依賴特定類別。
  16. TODO() 尚未實作前,執行時會造成程式中止。

參考資料:Kotlin-InterfacesKotlin-Nested classesKotlin-Object declarations


結語

抽象類別、介面與物件宣告,是 Kotlin 物件導向設計中非常重要的一環。抽象類別可以整理具有共同狀態的類別家族;介面可以將繪圖、點擊、計算與付款等能力拆開;objectcompanion object 則能管理共用物件、常數及建立流程。參考資料:Kotlin 官方文件

在 Android 開發中,這些觀念隨處可見。例如 Activity 繼承 AppCompatActivity、點擊監聽器實作 View.OnClickListener、Fragment 使用 companion object 提供 newInstance(),以及透過介面替換資料來源、藍牙連線方式或付款策略。理解今天的內容後,未來閱讀 Android 架構程式會容易許多。參考資料:Android Developers-App architecture

下一篇可以正式回到 Android 畫面開發,認識 EditTextCheckBoxRadioButtonRadioGroupSwitchToast,進一步完成使用者資料輸入與選項互動。參考資料:Android Developers-Input controls


上一篇
Android 點擊事件實戰——計數器、圖片切換與 ImageButton
系列文
Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言